All files / web/src/app/api/curriculum/[playerId]/sessions/[sessionId]/problems/[problemNumber]/metadata route.ts

0% Statements 0/128
0% Branches 0/1
0% Functions 0/1
0% Lines 0/128

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129                                                                                                                                                                                                                                                                 
/**
 * API route for fetching per-problem vision recording metadata
 *
 * GET /api/curriculum/[playerId]/sessions/[sessionId]/problems/[problemNumber]/metadata
 *
 * Returns the JSON metadata file containing time-coded vision and practice state data
 * for synchronized playback of recorded problem attempts.
 */

export const dynamic = 'force-dynamic'

import { readFile, access } from 'fs/promises'
import path from 'path'
import { NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import { sessionPlans, visionProblemVideos } from '@/db/schema'
import { withAuth } from '@/lib/auth/withAuth'
import { getPlayerAccess, generateAuthorizationError } from '@/lib/classroom'
import { getUserId } from '@/lib/viewer'
import type { ProblemMetadata } from '@/lib/vision/recording'

/**
 * GET - Fetch problem metadata JSON
 *
 * Query params:
 * - epoch: Epoch number (0 = initial pass, 1-2 = retry epochs). Defaults to 0.
 * - attempt: Attempt number within the epoch (1-indexed). Defaults to 1.
 */
export const GET = withAuth(async (request, { params }) => {
  try {
    const {
      playerId,
      sessionId,
      problemNumber: problemNumberStr,
    } = (await params) as { playerId: string; sessionId: string; problemNumber: string }
    const { searchParams } = new URL(request.url)

    if (!playerId || !sessionId || !problemNumberStr) {
      return NextResponse.json(
        { error: 'Player ID, Session ID, and Problem Number required' },
        { status: 400 }
      )
    }

    const problemNumber = parseInt(problemNumberStr, 10)
    if (isNaN(problemNumber) || problemNumber < 1) {
      return NextResponse.json({ error: 'Invalid problem number' }, { status: 400 })
    }

    // Parse epoch and attempt from query params
    const epochNumber = parseInt(searchParams.get('epoch') ?? '0', 10)
    const attemptNumber = parseInt(searchParams.get('attempt') ?? '1', 10)

    // Authorization check
    const userId = await getUserId()
    const playerAccess = await getPlayerAccess(userId, playerId)
    if (playerAccess.accessLevel === 'none') {
      const authError = generateAuthorizationError(playerAccess, 'view', {
        actionDescription: 'view recordings for this student',
      })
      return NextResponse.json(authError, { status: 403 })
    }

    // Verify session exists and belongs to player
    const session = await db.query.sessionPlans.findFirst({
      where: and(eq(sessionPlans.id, sessionId), eq(sessionPlans.playerId, playerId)),
    })

    if (!session) {
      return NextResponse.json({ error: 'Session not found' }, { status: 404 })
    }

    // Get problem video record with epoch/attempt filtering
    const video = await db.query.visionProblemVideos.findFirst({
      where: and(
        eq(visionProblemVideos.sessionId, sessionId),
        eq(visionProblemVideos.problemNumber, problemNumber),
        eq(visionProblemVideos.epochNumber, epochNumber),
        eq(visionProblemVideos.attemptNumber, attemptNumber)
      ),
    })

    if (!video) {
      return NextResponse.json({ error: 'Problem video not found' }, { status: 404 })
    }

    // Build metadata file path from video filename
    // New pattern: problem_NNN_eX_aY.meta.json (derived from video.filename)
    const baseName = video.filename.replace('.mp4', '')
    const metadataFilename = `${baseName}.meta.json`
    const metadataPath = path.join(
      process.cwd(),
      'data',
      'uploads',
      'vision-recordings',
      playerId,
      sessionId,
      metadataFilename
    )

    // Check if metadata file exists
    try {
      await access(metadataPath)
    } catch {
      // Metadata file doesn't exist - return empty metadata structure
      // This can happen for older recordings before metadata was implemented
      const emptyMetadata: ProblemMetadata = {
        slotId: '',
        problem: { terms: [], answer: 0 },
        entries: [],
        durationMs: video.durationMs ?? 0,
        frameCount: 0,
        isCorrect: null,
      }
      return NextResponse.json(emptyMetadata)
    }

    // Read and parse metadata file
    const metadataContent = await readFile(metadataPath, 'utf-8')
    const metadata: ProblemMetadata = JSON.parse(metadataContent)

    return NextResponse.json(metadata)
  } catch (error) {
    console.error('Error fetching problem metadata:', error)
    return NextResponse.json({ error: 'Failed to fetch metadata' }, { status: 500 })
  }
})